File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { useRouter } from 'next/navigation';
import { useActionState, useEffect, useState, useTransition } from 'react';
import { FoxButton } from '@fox/core/components/fox-button';
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
import { FoxCard } from '@fox/core/components/fox-card';
import { FoxDescriptionList } from '@fox/core/components/fox-description-list';
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
import { FoxInput } from '@fox/core/components/fox-input';
import { FoxPageHeader } from '@fox/core/components/fox-page-header';
import { FoxSelect } from '@fox/core/components/fox-select';
import { openPostcodeSearch } from '@/app/_components/postcode';
import { submitFormAction } from '@/app/_hooks/submit-form-action';
import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
import { useFeedback } from '@/app/_hooks/use-feedback';
import type { CommonCode } from '@/lib/domain/common-code';
import {
MERCHANT_DETAIL_VISIBILITY_OPTIONS,
MERCHANT_VOUCHER_USE_OPTIONS,
formatMerchantDetailStatusLabel,
formatOptionalValue,
type MerchantDetail,
type MerchantDetailSummary,
} from '@/lib/domain/merchant-detail';
import {
ADDRESS_MAX_LENGTH,
CLOSED_DAYS_MAX_LENGTH,
DESCRIPTION_MAX_LENGTH,
INITIAL_MERCHANT_DETAIL_FORM_STATE,
MERCHANT_NAME_MAX_LENGTH,
SORT_ORDER_MAX,
} from '@/lib/domain/merchant-detail-form';
import { MERCHANT_DETAILS_PATH, merchantDetailPath } from '@/lib/domain/merchant-detail-query';
import type { MerchantProduct } from '@/lib/domain/merchant-product';
import {
deleteMerchantDetailAction,
saveMerchantDetailAction,
updateMerchantDetailVisibilityAction,
} from '../../_actions';
import { MerchantChoiceChips } from '../../_components/merchant-choice-chips';
import { MerchantImageField } from '../../_components/merchant-image-field';
import { MerchantProductTable } from '../../_components/merchant-product-table';
import styles from './merchant-detail-form.module.scss';
interface MerchantDetailFormProps {
/** 상세가 있는 가맹점 — 고친다. */
detail?: MerchantDetail;
/** 상세가 아직 없는데 가맹점은 있는 경우(행의 [수정]) — 그 가맹점에 처음 만든다. */
merchant?: MerchantDetailSummary;
products?: MerchantProduct[];
categories: CommonCode[];
/** 메뉴 표의 배지 코드표(`FRCS_BADGE_CD`). */
badgeCodes?: CommonCode[];
}
/**
* 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 있으면
* 고치고, `merchant`면 그 가맹점에 처음 만들고, 둘 다 없으면 업체(가맹점)부터 새로 만든다.
* 첫 저장 뒤에는 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후
* 저장은 그 자리에 머문다.
*
* 업체명·연락처·우편번호·주소는 여기서 직접 입력한다(사용자 확정) — 저장하면 액션이 가맹점을
* 만들거나(코드 없음) 바뀐 값을 고친(코드 있음) 뒤 상세를 붙인다. 바뀌었는지는 숨은 원본 값과 비교한다.
*/
export function MerchantDetailForm({
detail,
merchant: fixedMerchant,
products = [],
categories,
badgeCodes = [],
}: MerchantDetailFormProps) {
const router = useRouter();
const { showAlert, hideAlert, showToast } = useFeedback();
const [isBusy, startAction] = useBlockingAction();
const [state, formAction, isPending] = useActionState(
saveMerchantDetailAction,
INITIAL_MERCHANT_DETAIL_FORM_STATE
);
const isEdit = detail !== undefined;
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
// 가맹점이 있으면(상세가 있든 없든) 그 코드로 고치고, 없으면 빈 코드로 보내 새로 만든다.
const merchant: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null;
const frcsNo = merchant?.id ?? '';
const original = {
name: merchant?.name ?? '',
phoneNumber: merchant?.phoneNumber ?? '',
postalCode: merchant?.postalCode ?? '',
address: merchant?.address ?? '',
addressDetail: merchant?.addressDetail ?? '',
};
// 우편번호·주소는 검색으로 채우거나 직접 고친다.
const [postalCode, setPostalCode] = useState(original.postalCode);
const [address, setAddress] = useState(original.address);
const [isSearching, startSearching] = useTransition();
const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? '');
const [visibility, setVisibility] = useState<string[]>([
detail?.isVisible === false ? 'N' : 'Y',
]);
const [voucherUse, setVoucherUse] = useState<string[]>(['Y']);
useEffect(() => {
if (state.status !== 'success') return;
if (state.created) {
showToast({ variant: 'success', message: '등록했습니다. 이어서 메뉴를 추가할 수 있습니다.' });
router.replace(merchantDetailPath(state.frcsNo));
} else {
showToast({ variant: 'success', message: '저장했습니다.' });
router.refresh();
}
}, [state, router, showToast]);
/** 기획 BO-MAL-002 [주소 검색] — 고른 주소로 우편번호·주소를 채운다. */
function searchAddress() {
startSearching(async () => {
let picked;
try {
picked = await openPostcodeSearch();
} catch {
showToast({ variant: 'danger', message: '주소 검색을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.' });
return;
}
if (!picked) return;
setPostalCode(picked.zonecode);
setAddress(picked.address);
});
}
function toggleVisibility() {
if (!detail) return;
const next = detail.isVisible === false;
startAction(async () => {
const result = await updateMerchantDetailVisibilityAction(detail.id, next);
showToast(
result.status === 'error'
? { variant: 'danger', message: result.message }
: { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' }
);
});
}
function runDelete() {
if (!detail) return;
hideAlert();
startAction(async () => {
const result = await deleteMerchantDetailAction(detail.id);
if (result.status === 'error') {
showToast({ variant: 'danger', message: result.message });
return;
}
showToast({ variant: 'success', message: '삭제했습니다.' });
router.push(MERCHANT_DETAILS_PATH);
});
}
function confirmDelete() {
if (!detail) return;
showAlert({
variant: 'danger',
title: '가맹몰 상세를 삭제하시겠습니까?',
message: `"${detail.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
actions: (
<FoxButtonGroup size="md">
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
<FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
</FoxButtonGroup>
),
});
}
return (
<>
<FoxPageHeader
title="가맹몰 상세 정보"
description="지역상생몰에 노출할 업체의 상세를 등록·수정한다. 저장 즉시 앱에 반영된다"
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '관리자관리' },
{ label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH },
{ label: '상세 정보' },
]}
/>
<form
id="merchant-detail-form"
onSubmit={(event) => submitFormAction(event, formAction)}
className={styles.form}
>
<input type="hidden" name="frcsNo" value={frcsNo} />
<input type="hidden" name="mode" value={isEdit ? 'edit' : 'create'} />
{/* 저장 시 업체 값이 바뀌었는지 비교할 원본 — 안 바뀌었으면 가맹점 수정 요청을 보내지 않는다. */}
<input type="hidden" name="originalname" value={original.name} />
<input type="hidden" name="originalphoneNumber" value={original.phoneNumber} />
<input type="hidden" name="originalpostalCode" value={original.postalCode} />
<input type="hidden" name="originaladdress" value={original.address} />
<input type="hidden" name="originaladdressDetail" value={original.addressDetail} />
<FoxCard size="lg" title="기본 정보" className={styles.section}>
<div className={styles.grid}>
<div className={styles.fieldWide}>
<FoxInput
size="md"
name="name"
label="업체명"
requirement="required"
placeholder="예) 타볼라타"
defaultValue={original.name}
maxLength={MERCHANT_NAME_MAX_LENGTH}
invalid={Boolean(errors.name)}
message={errors.name}
/>
</div>
<div className={styles.field}>
<FoxSelect
size="md"
label="카테고리"
requirement="required"
placeholder="선택"
options={categories.map((code) => ({ value: code.code, label: code.label }))}
value={categoryCode}
onValueChange={setCategoryCode}
error={Boolean(errors.categoryCode)}
/>
<input type="hidden" name="categoryCode" value={categoryCode} />
{errors.categoryCode && <FoxHelperText type="danger" message={errors.categoryCode} />}
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="phoneNumber"
label="업체 연락처"
requirement="required"
placeholder="0507-1413-6625"
defaultValue={original.phoneNumber}
invalid={Boolean(errors.phoneNumber)}
message={errors.phoneNumber}
/>
</div>
</div>
<div className={styles.grid}>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="postalCode"
label="우편번호"
requirement="required"
placeholder="00000"
value={postalCode}
onChange={setPostalCode}
invalid={Boolean(errors.postalCode)}
message={errors.postalCode}
/>
</div>
<div className={styles.searchButton}>
<FoxButton
type="default"
size="md"
label={isSearching ? '검색 중...' : '주소 검색'}
disabled={isSearching}
onAction={searchAddress}
/>
</div>
<div className={styles.fieldWide}>
<FoxInput
size="md"
name="address"
label="주소"
placeholder="도로명 주소 (검색 결과 자동 입력)"
value={address}
onChange={setAddress}
maxLength={ADDRESS_MAX_LENGTH}
invalid={Boolean(errors.address)}
message={errors.address}
/>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="addressDetail"
label="상세주소"
placeholder="건물명 · 층 · 호"
defaultValue={original.addressDetail}
maxLength={ADDRESS_MAX_LENGTH}
/>
</div>
</div>
<div className={styles.grid}>
<div className={styles.fieldWide}>
<FoxInput
size="md"
name="description"
label="한줄 소개"
placeholder="앱 목록·상세에 노출되는 한 줄 설명"
defaultValue={detail?.description ?? ''}
maxLength={DESCRIPTION_MAX_LENGTH}
invalid={Boolean(errors.description)}
message={errors.description}
/>
</div>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="openTime"
type="time"
label="영업 시작"
defaultValue={detail?.openTime ?? ''}
invalid={Boolean(errors.openTime)}
message={errors.openTime}
/>
</div>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="closeTime"
type="time"
label="영업 종료"
defaultValue={detail?.closeTime ?? ''}
invalid={Boolean(errors.closeTime)}
message={errors.closeTime}
/>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="closedDays"
label="휴무일"
placeholder="예) 매주 월요일"
defaultValue={detail?.closedDays ?? ''}
maxLength={CLOSED_DAYS_MAX_LENGTH}
invalid={Boolean(errors.closedDays)}
message={errors.closedDays}
/>
</div>
</div>
<div className={styles.grid}>
<MerchantChoiceChips
label="노출 상태"
name="isVisible"
required
options={MERCHANT_DETAIL_VISIBILITY_OPTIONS}
values={visibility}
onChange={setVisibility}
/>
<MerchantChoiceChips
label="바우처 사용"
name="isVoucherUsable"
required
options={MERCHANT_VOUCHER_USE_OPTIONS}
values={voucherUse}
onChange={setVoucherUse}
/>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="sortOrder"
type="number"
inputMode="numeric"
min={0}
max={SORT_ORDER_MAX}
label="노출 순서"
placeholder="숫자 (낮을수록 상단)"
defaultValue={detail?.sortOrder == null ? '' : String(detail.sortOrder)}
invalid={Boolean(errors.sortOrder)}
message={errors.sortOrder}
/>
</div>
</div>
</FoxCard>
<FoxCard
size="lg"
title="이미지 등록"
description="대표 1장 필수 · 추가 최대 11장 (앱 슬라이더 최대 12장)"
className={styles.section}
>
{/* 저장으로 파일 id가 바뀌면 다시 마운트해 칸 상태를 서버 값으로 되돌린다. */}
<MerchantImageField
key={`${detail?.thumbnailFileId ?? ''}/${detail?.imageFileId ?? ''}`}
defaultThumbnailUrl={detail?.thumbnailUrl ?? null}
retainedThumbnailId={detail?.thumbnailFileId ?? null}
defaultImageUrls={detail?.imageUrls ?? []}
retainedImageFileId={detail?.imageFileId ?? null}
thumbnailError={errors.thumbnail}
imagesError={errors.images}
/>
</FoxCard>
<FoxCard
size="lg"
title="메뉴 정보"
description="앱 상세 화면의 메뉴 목록에 노출된다"
className={styles.section}
>
<MerchantProductTable frcsNo={frcsNo} badgeCodes={badgeCodes} products={products} canAdd={isEdit} />
</FoxCard>
{detail && (
<FoxCard size="lg" title="등록 정보" className={styles.section}>
<FoxDescriptionList
layout="inline"
items={[
{ key: 'registeredAt', term: '등록일시', description: formatOptionalValue(detail.registeredAt) },
{ key: 'registeredBy', term: '등록자', description: formatOptionalValue(detail.registeredBy) },
{
key: 'modified',
term: '최종 수정',
description: [detail.modifiedAt, detail.modifiedBy]
.filter((value): value is string => Boolean(value))
.join(' / ') || '-',
},
{ key: 'status', term: '현재 상태', description: formatMerchantDetailStatusLabel(detail) },
]}
/>
</FoxCard>
)}
{state.status === 'error' && state.message && (
<FoxHelperText type="danger" message={state.message} />
)}
<FoxButtonPanel>
<FoxButtonGroup size="lg">
{detail && (
<>
<FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} />
<FoxButton
type="default"
size="lg"
label={detail.isVisible === false ? '노출' : '비활성'}
disabled={isBusy}
onAction={toggleVisibility}
/>
</>
)}
<FoxButton
type="default"
size="lg"
label="취소"
onAction={() => router.push(MERCHANT_DETAILS_PATH)}
/>
<FoxButton
type="primary"
size="lg"
htmlType="submit"
label={isEdit ? '저장' : '등록'}
loading={isPending}
/>
</FoxButtonGroup>
</FoxButtonPanel>
</form>
</>
);
}